@scalewing/react 1.41.0 → 1.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +180 -1
  3. package/dist/components/ActionMenu.d.ts +4 -2
  4. package/dist/components/ActionMenu.js +4 -8
  5. package/dist/components/Article.d.ts +60 -0
  6. package/dist/components/Article.js +42 -0
  7. package/dist/components/DescriptionList.d.ts +12 -2
  8. package/dist/components/DescriptionList.js +7 -3
  9. package/dist/components/DocumentViewer.d.ts +41 -0
  10. package/dist/components/DocumentViewer.js +72 -0
  11. package/dist/components/FileField.d.ts +55 -0
  12. package/dist/components/FileField.js +75 -0
  13. package/dist/components/FilterChips.d.ts +1 -1
  14. package/dist/components/FilterChips.js +1 -1
  15. package/dist/components/FilterChipsRow.d.ts +24 -0
  16. package/dist/components/FilterChipsRow.js +14 -0
  17. package/dist/components/ListRow.d.ts +31 -2
  18. package/dist/components/ListRow.js +19 -18
  19. package/dist/components/MarkdownView.d.ts +39 -0
  20. package/dist/components/MarkdownView.js +30 -0
  21. package/dist/components/NavCount.d.ts +15 -2
  22. package/dist/components/NavCount.js +16 -6
  23. package/dist/components/action-menu/assert-action-menu.d.ts +13 -0
  24. package/dist/components/action-menu/assert-action-menu.js +21 -0
  25. package/dist/components/chart/use-chart-width.js +2 -24
  26. package/dist/components/description-list-layout.d.ts +10 -0
  27. package/dist/components/description-list-layout.js +13 -0
  28. package/dist/components/document-viewer/document-viewer-labels.d.ts +23 -0
  29. package/dist/components/document-viewer/document-viewer-labels.js +23 -0
  30. package/dist/components/document-viewer/page-box.d.ts +8 -0
  31. package/dist/components/document-viewer/page-box.js +12 -0
  32. package/dist/components/document-viewer/pdf-document.d.ts +32 -0
  33. package/dist/components/document-viewer/pdf-document.js +66 -0
  34. package/dist/components/document-viewer/pdf-options.d.ts +36 -0
  35. package/dist/components/document-viewer/pdf-options.js +46 -0
  36. package/dist/components/document-viewer/render-pdf-page.d.ts +20 -0
  37. package/dist/components/document-viewer/render-pdf-page.js +38 -0
  38. package/dist/components/document-viewer/scroll-chain.d.ts +6 -0
  39. package/dist/components/document-viewer/scroll-chain.js +23 -0
  40. package/dist/components/document-viewer/use-error-report.d.ts +5 -0
  41. package/dist/components/document-viewer/use-error-report.js +13 -0
  42. package/dist/components/document-viewer/use-page-render.d.ts +21 -0
  43. package/dist/components/document-viewer/use-page-render.js +70 -0
  44. package/dist/components/document-viewer/use-pdf-document.d.ts +22 -0
  45. package/dist/components/document-viewer/use-pdf-document.js +47 -0
  46. package/dist/components/document-viewer/use-pinch-zoom.d.ts +12 -0
  47. package/dist/components/document-viewer/use-pinch-zoom.js +67 -0
  48. package/dist/components/document-viewer/use-zoom.d.ts +23 -0
  49. package/dist/components/document-viewer/use-zoom.js +70 -0
  50. package/dist/components/document-viewer/zoom.d.ts +29 -0
  51. package/dist/components/document-viewer/zoom.js +37 -0
  52. package/dist/components/file-field/file-field-checks.d.ts +13 -0
  53. package/dist/components/file-field/file-field-checks.js +23 -0
  54. package/dist/components/file-field/use-file-drop.d.ts +34 -0
  55. package/dist/components/file-field/use-file-drop.js +78 -0
  56. package/dist/components/file-field/use-swap-focus.d.ts +15 -0
  57. package/dist/components/file-field/use-swap-focus.js +40 -0
  58. package/dist/components/filter-chips/FilterChip.js +1 -1
  59. package/dist/components/filter-chips/options.d.ts +10 -0
  60. package/dist/components/filter-chips/options.js +8 -0
  61. package/dist/components/filter-chips/validate.d.ts +3 -3
  62. package/dist/components/filter-chips/validate.js +8 -4
  63. package/dist/components/list/ListRowContent.d.ts +9 -2
  64. package/dist/components/list/ListRowContent.js +6 -3
  65. package/dist/components/list/ListRowItem.d.ts +10 -0
  66. package/dist/components/list/ListRowItem.js +17 -0
  67. package/dist/components/list/row-naming.d.ts +17 -0
  68. package/dist/components/list/row-naming.js +19 -0
  69. package/dist/components/markdown/markdown-limits.d.ts +14 -0
  70. package/dist/components/markdown/markdown-limits.js +14 -0
  71. package/dist/components/markdown/markdown-model.d.ts +94 -0
  72. package/dist/components/markdown/markdown-model.js +8 -0
  73. package/dist/components/markdown/markdown-options.d.ts +18 -0
  74. package/dist/components/markdown/markdown-options.js +10 -0
  75. package/dist/components/markdown/markdown-rules.d.ts +28 -0
  76. package/dist/components/markdown/markdown-rules.js +81 -0
  77. package/dist/components/markdown/markdown-text.d.ts +17 -0
  78. package/dist/components/markdown/markdown-text.js +86 -0
  79. package/dist/components/markdown/parse-markdown.d.ts +4 -0
  80. package/dist/components/markdown/parse-markdown.js +21 -0
  81. package/dist/components/markdown/read-blocks.d.ts +8 -0
  82. package/dist/components/markdown/read-blocks.js +168 -0
  83. package/dist/components/markdown/read-inlines.d.ts +12 -0
  84. package/dist/components/markdown/read-inlines.js +126 -0
  85. package/dist/components/markdown/read-markdown.d.ts +9 -0
  86. package/dist/components/markdown/read-markdown.js +27 -0
  87. package/dist/components/markdown/reading-context.d.ts +18 -0
  88. package/dist/components/markdown/reading-context.js +35 -0
  89. package/dist/components/markdown/render-markdown.d.ts +10 -0
  90. package/dist/components/markdown/render-markdown.js +52 -0
  91. package/dist/components/slot.d.ts +9 -0
  92. package/dist/components/slot.js +13 -0
  93. package/dist/components/use-element-width.d.ts +11 -0
  94. package/dist/components/use-element-width.js +38 -0
  95. package/dist/css/css-action-menu-trigger.d.ts +6 -2
  96. package/dist/css/css-action-menu-trigger.js +50 -2
  97. package/dist/css/css-action-menu.js +4 -2
  98. package/dist/css/css-article.d.ts +2 -0
  99. package/dist/css/css-article.js +110 -0
  100. package/dist/css/css-components.js +15 -0
  101. package/dist/css/css-count-pill.js +14 -1
  102. package/dist/css/css-description-list.d.ts +1 -0
  103. package/dist/css/css-description-list.js +24 -0
  104. package/dist/css/css-document-viewer.d.ts +2 -0
  105. package/dist/css/css-document-viewer.js +186 -0
  106. package/dist/css/css-file-field.d.ts +2 -0
  107. package/dist/css/css-file-field.js +194 -0
  108. package/dist/css/css-filter-chip-tones.d.ts +3 -0
  109. package/dist/css/css-filter-chip-tones.js +28 -0
  110. package/dist/css/css-filter-chips-layout.d.ts +1 -0
  111. package/dist/css/css-filter-chips-layout.js +1 -1
  112. package/dist/css/css-filter-chips-row.d.ts +2 -0
  113. package/dist/css/css-filter-chips-row.js +79 -0
  114. package/dist/css/css-filter-chips.js +8 -0
  115. package/dist/css/css-font-mono.d.ts +3 -0
  116. package/dist/css/css-font-mono.js +23 -0
  117. package/dist/css/css-list-row-trailing.d.ts +2 -0
  118. package/dist/css/css-list-row-trailing.js +91 -0
  119. package/dist/css/css-list-row-truncate.d.ts +2 -0
  120. package/dist/css/css-list-row-truncate.js +29 -0
  121. package/dist/css/css-list.js +9 -1
  122. package/dist/css/css-markdown.d.ts +2 -0
  123. package/dist/css/css-markdown.js +144 -0
  124. package/dist/css/css-utilities.js +3 -0
  125. package/dist/css/css-variables.js +1 -0
  126. package/dist/css/spacing-classes.d.ts +1 -1
  127. package/dist/css/spacing-classes.js +1 -0
  128. package/dist/css/stylesheet.js +10 -0
  129. package/dist/document-viewer-worker.d.ts +9 -0
  130. package/dist/document-viewer-worker.js +9 -0
  131. package/dist/index.d.ts +11 -5
  132. package/dist/index.js +8 -3
  133. package/dist/list-options.d.ts +5 -2
  134. package/dist/list-options.js +7 -2
  135. package/dist/styles.css +649 -0
  136. package/package.json +10 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.44.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2af39a5: `ActionMenu` `triggerAppearance="icon"`: the consumer's `trigger` glyph alone in a round quiet button as wide as it is tall (the sm control height, 44 px on a coarse pointer), muted with no fill, outline or chevron, the text colour on the quiet fill on hover and while open; named by `label`, for a row's own ••• menu. An icon trigger without a glyph throws a `RangeError`. New class `sw-action-menu-trigger-icon`; `actionMenuTriggerAppearances` gains `icon`. No new tokens, no new dependency. Request: `docs/requests/teisoro-action-menu-icon-trigger.md`.
8
+ - 38a1715: `DescriptionList` `layout`: `columns` (the default, unchanged) or `spread`, each row's term at its start and its detail at its end, end-aligned, side by side at every width, for a short box of facts. New class `sw-description-list-spread`, new exports `descriptionListLayouts` and `DescriptionListLayout`; an unknown layout throws a `RangeError`. No new tokens, no new dependency. Request: `docs/requests/teisoro-description-list-spread.md`.
9
+ - f426251: `FileField` `value.preview`: the address of a small image of a chosen photo, such as an object URL the consumer made from the `File`, drawn as a decorative 3:2 thumbnail (a space-8 by a space-6, cropped to fill, at the small radius) before the file's name in place of `icon`. A blank preview throws a `RangeError`. New class `sw-file-field-preview`. No new tokens, no new dependency. Request: `docs/requests/teisoro-file-field-preview.md`.
10
+ - 76ae2b4: `FilterChips` option `tone`: `neutral` (the default, unchanged) or `warning`, which draws that chip, while it is not chosen, in the warning tint with its words and count in the warning ink (a warning `Notice`'s, 4.5:1 in every palette and scheme); chosen it is the accent and at a zero count quiet, like every chip. New class `sw-filter-chip-warning`, new exports `filterChipTones` and `FilterChipTone`; an unknown tone throws a `RangeError`. No new tokens, no new dependency. Request: `docs/requests/teisoro-filter-chip-tone.md`.
11
+ - 49f90af: `FilterChipsRow`: several `FilterChips` groups on one row, each keeping its own name, choices and Tab stops, an optional `end` group at the row's end (at its own line's end when the row wraps), and `overflow` `wrap` (the default) or `scroll`, where every group's chips stay on one row that scrolls sideways together inside the row's own box, never the page, a group's own scroll giving way. New classes `sw-filter-chips-row`, `sw-filter-chips-row-end`, `sw-filter-chips-row-scroll`; new exports `FilterChipsRow` and `FilterChipsRowProps`; an unknown overflow throws a `RangeError`. The private slot check `hasSlot` moved from the list rows to `components/slot.ts`, shared by both. No new tokens, no new dependency. Request: `docs/requests/teisoro-filter-chips-row.md`.
12
+ - a6c790c: `ListRow` `truncate`: opt-in, keeps the title and the detail line to one line each, cut with "…" where they do not fit; the unread dot follows the cut words, the whole words stay in the row's name, description and a `title` tooltip, and the value, tag and action keep their words whole. New classes `sw-list-row-truncate` and `sw-list-row-title-text`. Rows without it are unchanged. No new tokens, no new dependency. Request: `docs/requests/teisoro-list-row-truncate.md`.
13
+ - d4647b8: `sw-container-reading`: a utility class that centres a column at the reading width (`--sw-reading-max`, 46rem, `Article`'s), as `sw-container` does at the container width, and full width below it; for a form or text that reads better narrower than the page. No new tokens, no new dependency. Request: `docs/requests/teisoro-container-reading.md`.
14
+ - 7da3f25: `sw-font-mono`: a utility class that sets an element, or a native text control such as a `Field`'s `textarea`, in the platform's monospace face (`ui-monospace, monospace`, the generic CSS names only, so no face is chosen) at the size it already has, outranking the canvas's text control rules. For words read as typed code or marks, such as a Markdown source being written. No new tokens, no new dependency. Request: `docs/requests/teisoro-font-mono.md`.
15
+
16
+ ### Patch Changes
17
+
18
+ - 68be1ea: `ListRow` `truncate`: the one-line rule for the title's words box (`sw-list-row-title-text`) is scoped to truncated rows (`.sw-list-row-truncate .sw-list-row-title-text`), as the detail's already was; a truncated row looks the same.
19
+
20
+ ## 1.43.0
21
+
22
+ Not published: its release validation failed before publication (a gallery test timed out, fixed in #211). Its changes ship in 1.44.0, listed there.
23
+
24
+ ## 1.42.0
25
+
26
+ ### Minor Changes
27
+
28
+ - 5218882: `Article`: a page that reads like a post, on a card (`variant`, as `Card`'s) no wider than the new `--sw-reading-max` (46rem): an optional `cover` photo (`{ src, alt }` or `null`) edge to edge across the top, the `eyebrow` with the consumer's `actions` at its row's end, the `title` in heading type (`titleAs` `h1`, `h2` or `h3`; it names the `article` landmark, which the ref reaches), the muted `meta` line, a hairline and the body. New exports `Article`, `ArticleProps`, `ArticleCover`, `ArticleTitleElement`, `articleTitleElements`; new `sw-article*` classes. No new tokens, no new dependency. Request: `docs/requests/teisoro-article.md`.
29
+ - 6befd6c: `DocumentViewer`: a PDF drawn from its bytes (never a URL) one page at a time with PDF.js, fitted to its width, with 48-point previous and next steps around "Page n of m" in a polite live region, pinch and `+`/`-`/`0` zoom, a transparent text layer for assistive technology and selection, the consumer's loading and failed words and actions, and no download or print of its own. New exports `DocumentViewer`, `DocumentViewerProps`, `DocumentViewerLabels`; new export path `@scalewing/react/document-viewer-worker` (the PDF.js worker entry, bundled by the consumer, such as Vite's `?worker&url`); new `sw-document-viewer*` classes.
30
+
31
+ New production dependency: `pdfjs-dist` 6.4.299, pinned exactly. Rationale: drawing PDFs needs a full parser and renderer, and PDF.js (Mozilla, Apache-2.0) is the maintained browser-grade one, at the version a server checking the same uploads can pin too. It is loaded with a dynamic import only when a viewer opens a document, its legacy build is used for older browsers, and it runs hardened: nothing fetched beyond the bytes, no eval, no XFA, no scripting. Its optional `@napi-rs/canvas` (Node only, 28 to 35 MB per platform) is never loaded in a browser and can be skipped with pnpm's `ignoredOptionalDependencies`; `pdfjs-dist` asks for Node 22.13 or later. Request: `docs/requests/teisoro-document-viewer.md`.
32
+
33
+ - d387481: `FileField`: one file chosen with the system picker or dropped on the field, with `Field`'s label, description and error. Empty, a wide zone named by the label and `chooseLabel` (`dropLabel` after it where the pointer can drag); chosen (`value={{ name, detail }}`), the file's name and detail with the consumer's `icon` and a `changeLabel` button. `onChange` receives the `File`; nothing is read or uploaded, and a dropped file is not filtered by `accept`. New exports `FileField`, `FileFieldProps`, `FileFieldValue`; new `sw-file-field*` classes. No new tokens, no new dependency. Request: `docs/requests/teisoro-file-field.md`.
34
+ - 33a6d6e: `ListRow` `unread` / `unreadLabel`, `tag` and `action`: an accent dot after the title for a row the person has not opened, read as `unreadLabel` after the title (required while `unread`); a short status such as a `Badge` before the row's end, describing a pressable row; and a control such as an `ActionMenu` after the row, outside its press area, in the same list item. New classes `sw-list-row-unread`, `sw-list-row-tag`, `sw-list-item-action`, `sw-list-row-action`; an unread row without words throws a `RangeError`. Existing rows are unchanged, except that an empty `leading` (`null`, `false` or `''`, as `leading={isLate && <Dot />}` gives) no longer draws an empty mark box and its gap before the title. No new tokens, no new dependency. Request: `docs/requests/teisoro-list-row-trailing.md`.
35
+ - f027566: `MarkdownView` (`newTabLabel` required): Markdown drawn in Scalewing's type as React elements, never as HTML from a string: paragraphs, headings (`#` as an `h2`, with ids from their words and an optional `idPrefix`), lists, bold, italic, links (`http`, `https` and `mailto` only, read as the browser would, in a new tab with `rel="noopener noreferrer"`; reference links and autolinks too; any other scheme shows as plain words), block quotes as a highlighted box, line breaks, thematic breaks and `![caption](photo:<id>)` photos the consumer resolves (`resolveImage`) to a safe address, with words (their alt or caption) and captions. Raw HTML, scripts, tables, other images and unresolved or wordless photos are left out, code is shown as plain text, and quotes, lists and marks nested past 16 levels are shown as their words; the reading is linear, so a realistic 50,000-character page reads in about 7 ms and the slowest crafted one in about a tenth of a second. `inspectMarkdown(source, options)` returns the `headings` (for an "On this page" list), the first 100 `omissions` and `omissionCounts` (for an editor to say what will not be shown), refusing what the view refuses. New exports `MarkdownView`, `MarkdownViewProps`, `MarkdownOptions`, `inspectMarkdown`, `MarkdownInspection`, `MarkdownHeading`, `MarkdownImage`, `MarkdownImageResolver`, `MarkdownOmission`, `MarkdownOmissionKind`, `MarkdownOmissionCounts`, `markdownOmissionKinds`; new `sw-markdown*` classes. New production dependency, pinned exactly: `markdown-it` 14.3.2 (MIT; a CommonMark parser to tokens, linear in the text with capped nesting; its HTML renderer is never used), about 53 kB gzip, loaded only by apps that import the view. Request: `docs/requests/teisoro-markdown-view.md`.
36
+ - 8f70607: `NavCount` `tone?: 'danger' | 'accent'` (default `danger`): `accent` draws the count pill in the accent colour with its on-colour figures, for a count that is news (what was added since the person last looked) rather than work waiting. New exports `navCountTones`, `NavCountTone`; new class `sw-nav-count-accent`; an unknown tone throws a `RangeError`. Existing counts are unchanged. No new tokens, no new dependency. Request: `docs/requests/teisoro-nav-count-tone.md`.
37
+
3
38
  ## 1.41.0
4
39
 
5
40
  ### Minor Changes
package/README.md CHANGED
@@ -32,11 +32,14 @@ import {
32
32
  DescriptionItem,
33
33
  ListGroup,
34
34
  ListRow,
35
+ MarkdownView,
35
36
  DenominationGrid,
36
37
  FilterChips,
37
38
  Grid,
38
39
  DateField,
39
40
  Field,
41
+ FileField,
42
+ Article,
40
43
  Stepper,
41
44
  Steps,
42
45
  Step,
@@ -139,6 +142,25 @@ A disabled native text control (`<input>`, `<select>` or `<textarea>` on the can
139
142
  </Field>
140
143
  ```
141
144
 
145
+ `FileField` is one file chosen with the system picker or dropped on the field, with `Field`'s `label` (above it, or hidden with `labelVisuallyHidden`), `description` and `error` (a polite live region in place of the hint, the frame's edge in the danger color, `Mark` in forced colors). Empty, it is one wide zone on the subtle fill, a button named by the label and `chooseLabel` ("Field guide Choose a PDF"), with `dropLabel` ("or drop it here") muted after it where the pointer can drag (left out on a coarse pointer). Chosen (`value={{ name, detail? }}`), it shows the consumer's `icon` (hidden from assistive technology), the file's name over the muted `detail` and a neutral button named by the label and `changeLabel`, described by the name and detail. Either button opens the picker (the native input is hidden and out of the Tab order, its `accept` the list the picker offers); `onChange` receives the `File` chosen or dropped, the first of several, and when a choice or a drop swaps the zone and the chosen row while focus is in the field, focus moves to the button now there (focus the person moved elsewhere is never taken back). A file dragged over the field draws the accent edge and tint, its words in the text color. It never reads, checks or uploads the file, and a dropped file is not filtered by `accept`: check what you receive (type, size, its header) and say why in `error`, keeping the earlier `value` or none. `disabled` blocks the picker, refuses a dropped file and dashes the frame. `id` prefixes the ids of the field's parts. An empty `label`, `chooseLabel` or `changeLabel`, a blank `dropLabel`, or a `value` with no name or a blank detail throws a `RangeError`.
146
+
147
+ A chosen photo can show itself: `value.preview` is the address of a small image of it, such as an object URL the consumer made from the `File` (and revokes when another is chosen), drawn as a 3:2 thumbnail (a space-8 wide, a space-6 tall, cropped to fill, at the small radius) before the name in place of `icon`. It is decorative (`alt=""`): the name beside it says which file it is. A blank `preview` throws a `RangeError`.
148
+
149
+ ```tsx
150
+ <FileField
151
+ label="Field guide"
152
+ description="A PDF up to 25 MB"
153
+ accept=".pdf,application/pdf"
154
+ chooseLabel="Choose a PDF"
155
+ dropLabel="or drop it here"
156
+ changeLabel="Choose another"
157
+ icon={<FileText />}
158
+ value={file ? { name: file.name, detail: `${pages} pages · ${size}` } : null}
159
+ error={error}
160
+ onChange={(next) => checkPdf(next).then(setFile, (why) => setError(why))}
161
+ />
162
+ ```
163
+
142
164
  `Stepper` is a whole-number count changed with − and + or typed. The count is a native text input with `role="spinbutton"` and `inputmode="numeric"`, named by `label` (visible above it, or `labelVisuallyHidden` where table headers say it) and the one tab stop: ArrowUp and ArrowDown step it by `step` (default 1), Home goes to `min` and End to a finite `max` (a range below zero takes `inputmode="text"`, since a phone's numeric pad has no minus key). The − and + buttons are pointer targets outside the tab order, named by `decrementLabel` and `incrementLabel` (your copy), and a press on one leaves focus where it was; the count and both buttons sit in a `group` named by the label. − is disabled at `min` (default 0) and + at `max` (no upper bound when left out). `value` is a count or `null` for an empty box, which the buttons treat as `min` and `placeholder` can fill. Typed text is refused unless it is digits (with a leading minus when `min` is below zero), so `onChange` only receives a whole number or `null`; a typed count is reported as typed even outside the bounds, so say why in `error` (a polite message, `aria-invalid` and the danger border, as `Field`'s), and the buttons then move it back inside them. `aria-describedby` takes ids of elements that describe the count, such as the value it adds up to, read before an `error`'s message. `changed` takes `Field`'s changed look; `readOnly` keeps the count focusable and hides the buttons in their own room; `disabled` stops all three. `limitAppearance="plain"` (default `"faded"`) keeps − at `min` and + at `max` in their usual look instead of faded, for a count sheet where a faded − reads as broken; they are still disabled, a disabled or read-only Stepper is unchanged, forced colors still grey them, and an unknown value throws a `RangeError`. The count is a few digits wide and keeps that width (`width="fixed"`), or fills its container (`width="full"`, for a table cell). Bounds that are not whole numbers, `min` above `max`, or a `step` that is not above zero throw a `RangeError`.
143
165
 
144
166
  ```tsx
@@ -265,6 +287,8 @@ A `Button` that toggles something passes `aria-pressed`. The pressed button gets
265
287
 
266
288
  `ActionMenu` `triggerAppearance="text"` (opt-in; default `"framed"`, `actionMenuTriggerAppearances`) draws the trigger as its words (`trigger`, or `label`) in label type at medium weight in the text color, with the shared muted chevron pointing down after them and no fill or outline; the quiet grey fill shows on hover and while the menu is open. It is the sm control height (the 44 px target on a coarse pointer). Name the trigger with `label` as before, and include the words shown in it (WCAG 2.5.3, so voice control can say them): a person's name that opens their account menu takes a `label` such as "Account, Ada Okafor". The trigger's words are one flex item (mixed inline content keeps its spaces), then the chevron. Forced colors draw its border in the system's border color, the chevron in CanvasText, and an open trigger's border in Highlight. An unknown value throws a `RangeError`.
267
289
 
290
+ `triggerAppearance="icon"` (opt-in) draws the `trigger` glyph alone in a round quiet button, as wide as it is tall (the sm control height, 44 px on a coarse pointer), in the muted colour with no fill, outline or chevron; it turns to the text colour on the quiet fill on hover and while the menu is open. Use it for a row's own ••• menu, named by `label` ("More for Heron calls"); the glyph says nothing on its own. An icon trigger without a `trigger` glyph throws a `RangeError`. Forced colors draw it in ButtonText with its kept border, Highlight while open.
291
+
268
292
  ```tsx
269
293
  <ActionMenu
270
294
  header={
@@ -448,7 +472,7 @@ An option's optional `description` (a `ReactNode`) is secondary text for that op
448
472
 
449
473
  `appearance="words"` (opt-in; default `"default"`, `navAppearances`) draws every destination, a link or a `Button` of any variant, as plain words: no fill, border or underline, label type, the others in the muted color at medium weight and the current one (`aria-current`, not `"false"`) in the text color at label weight. A Button keeps its size, padding, touch target and focus ring; hover lifts a destination to the text color where the pointer can hover, never a disabled one. The current mark is color and weight, so each destination's words must be visible: a glyph-only destination has no weight to change (keep `default` for a glyph rail). Forced colors draw the current one's border in Highlight and underline its words. An unknown value throws a `RangeError`.
450
474
 
451
- `NavCount` puts a count after a destination's words, such as the tasks waiting in an area: place it inside the link or `Button`, after its words (`<a href="/tasks">Tasks<NavCount count={8} /></a>`). It is the pill `SectionNav`'s item `count` draws (the danger color with its on-color figures, an edge in forced colors), a space-2 after the words in a link or a Button, the link keeping its siblings' height, and it is read as part of the destination's name ("Tasks 8"). `count` is a whole number; 0 shows nothing. `countLabel` says what it means in your words ("8 tasks to do"): it is read in place of the bare number ("Tasks 8 tasks to do") and the pill is hidden from assistive technology. A count that is not a whole number from 0, or a blank `countLabel`, throws a `RangeError`.
475
+ `NavCount` puts a count after a destination's words, such as the tasks waiting in an area: place it inside the link or `Button`, after its words (`<a href="/tasks">Tasks<NavCount count={8} /></a>`). It is the pill `SectionNav`'s item `count` draws (the danger color with its on-color figures, an edge in forced colors), a space-2 after the words in a link or a Button, the link keeping its siblings' height, and it is read as part of the destination's name ("Tasks 8"). `count` is a whole number; 0 shows nothing. `countLabel` says what it means in your words ("8 tasks to do"): it is read in place of the bare number ("Tasks 8 tasks to do") and the pill is hidden from assistive technology. `tone="accent"` (default `"danger"`, `navCountTones`) draws the pill in the accent color with its on-color figures, for a count that is news rather than work waiting, such as what was added since the person last looked; its size, spacing and name are the same. Give an accent count a `countLabel` ("3 new") so its words, not its color, say it is news, and place either tone on a neutral or ghost destination, never a filled one of the same color. A count that is not a whole number from 0, a blank `countLabel` or an unknown `tone` throws a `RangeError`.
452
476
 
453
477
  ```tsx
454
478
  <Nav appearance="words" aria-label="Areas">
@@ -459,6 +483,10 @@ An option's optional `description` (a `ReactNode`) is secondary text for that op
459
483
  Tasks
460
484
  <NavCount count={8} countLabel="8 tasks to do" />
461
485
  </Box>
486
+ <Box as="a" href="/field-notes">
487
+ Field notes
488
+ <NavCount count={3} countLabel="3 new" tone="accent" />
489
+ </Box>
462
490
  </Nav>
463
491
  ```
464
492
 
@@ -620,6 +648,31 @@ A bar in its own column reads as empty cells to assistive technology, which is f
620
648
  />
621
649
  ```
622
650
 
651
+ `DocumentViewer` draws a PDF from its bytes (`data`, an `ArrayBuffer` or `Uint8Array`, never a URL) with PDF.js, one page at a time, fitted to the width it is given, on a quiet grey stage at the large radius. Under the page, "Page n of m" (`labels.page(page, pageCount)`) is a polite live region between 48-point round previous and next steps (`labels.previous`, `labels.next`), which look off at either end but stay Tab stops; with one page only the words show. `page` and `onPageChange` hold the page in the consumer (left out, the viewer keeps its own; a page out of range shows the nearest). Two fingers zoom the page up to four times as wide, keeping the spot of the page between them under the fingers (the stage scrolls across and the boxes around it, the window at last, scroll down; the stage only pans, so the screen does not zoom instead), and on the focused page `+`, `-` and `0` zoom in, out and back to fit width about the middle of what shows; a zoomed page scrolls sideways inside the stage and is drawn again at its size once the zoom rests. The page's text lies over the drawing, transparent (PDF.js's text layer), so assistive technology reads it and the person can select it. While the PDF opens it shows `labels.loading`; when it cannot be opened or a page cannot be drawn, `labels.failed`, an optional `labels.failedDetail` and your `failedAction` (such as Try again and Download PDF) in an alert, and it calls `onError`; when only one page cannot be drawn, the steps stay, so another page can be read. `onLoad` receives the page count. New `data` opens again at the first page, so Try again is new bytes or a new `key`; keep `data` the same object between renders (in state or a memo), since bytes made afresh on each render reopen the PDF every time. The viewer is a region landmark named by `label`; its stage, once open, is a focusable group named by the page words. It has no download or print of its own and opens nothing from the network: no font, character map, colour profile or WebAssembly file (PDF.js uses system fonts and skips what it cannot decode), no XFA, no streaming or range requests, and no code evaluation (`isEvalSupported: false`); it draws from a copy, so your bytes stay usable for a download. PDF.js (`pdfjs-dist`, its legacy build, for the browsers stores run) is loaded the first time a viewer opens a document, so a page without one never downloads it. Missing words, a blank `workerSrc` or a `labels.page` that is not a function throw a `RangeError`.
652
+
653
+ PDF.js parses in a worker. Scalewing ships the worker entry for its own pinned PDF.js version as `@scalewing/react/document-viewer-worker`; bundle it as a module worker and pass its URL as `workerSrc`. With Vite:
654
+
655
+ ```tsx
656
+ import { DocumentViewer } from '@scalewing/react';
657
+ import workerSrc from '@scalewing/react/document-viewer-worker?worker&url';
658
+
659
+ <DocumentViewer
660
+ data={bytes}
661
+ workerSrc={workerSrc}
662
+ label={guide.title}
663
+ labels={{
664
+ previous: t('reader.previousPage'),
665
+ next: t('reader.nextPage'),
666
+ page: (page, count) => t('reader.pageOf', { page, count }),
667
+ loading: t('reader.opening'),
668
+ failed: t('reader.failed'),
669
+ }}
670
+ failedAction={<Button onPress={retry}>{t('reader.tryAgain')}</Button>}
671
+ />;
672
+ ```
673
+
674
+ The consumer needs no PDF.js dependency of its own. With another bundler, build `@scalewing/react/document-viewer-worker` as a module worker (`new Worker(url, { type: 'module' })`) and pass the built file's URL. `pdfjs-dist` has an optional native canvas for Node (`@napi-rs/canvas`, 28 to 35 MB per platform) that a browser never loads; a pnpm consumer can skip it with `ignoredOptionalDependencies`. `pdfjs-dist` asks for Node 22.13 or later.
675
+
623
676
  `Spinner` shows indeterminate loading in small, medium, or large sizes. Supply localized `label` for the one announced status in a loading region. Use `decorative` on additional indicators beside that status so screen readers do not hear the same message repeatedly. Reduced motion leaves a static accented ring.
624
677
 
625
678
  `GlyphTile` is a small rounded square in a categorical colour with the consumer's glyph in white: the mark of a kind beside its name, such as a service in a list. `hue` is one of the `@scalewing/tokens` categorical colours (`green`, `blue`, `orange`, `purple`, `teal`, `rose`, or `neutral`); which kind takes which hue is yours. `size` is `sm` (24), `md` (28, the default) or `lg` (32); the glyph is drawn at spacing step 4 in white, which keeps 3:1 on every fill; pass the SVG (a Lucide component) as the tile's direct child, since only a direct-child SVG is sized. It is always `aria-hidden`, so put the kind's name in visible text beside it. An unknown `hue` or `size` throws a `RangeError`.
@@ -695,10 +748,54 @@ A bar in its own column reads as empty cells to assistive technology, which is f
695
748
  </Inline>
696
749
  ```
697
750
 
751
+ `Article` is a page that reads like a post, on a card (`variant`, as `Card`'s) no wider than the reading width (`--sw-reading-max`, 46rem): an optional `cover` photo (`{ src, alt }`, `alt` required, `''` when it only decorates) (or `null`) edge to edge across the top at 3:1 (2:1 below `md`) with the card's top corners; then, a space-6 from the top and bottom and a space-8 from the sides (space-5 all round below `md`), the `eyebrow` (muted caption type at the label's weight) with your `actions` at its row's end (such as a language switch; under the eyebrow, at its start, when the row is too narrow), the `title` in heading type (`titleAs`: `h1` by default, on the article's own page; `h2` or `h3` under other headings), the muted `meta` line (such as "Manager 1 · Oct 16 · 4 min read"), a hairline, and the body (`children`, your own content, such as paragraphs or Markdown your app draws). It is an `article` landmark named by its title, and its ref is that element; the eyebrow and meta are `div`s, so any content fits. The card sits at the start of a wider column. Empty parts draw nothing. A blank `title`, an unknown `titleAs` or `variant`, or a `cover` with a blank `src` or no `alt` throws a `RangeError`.
752
+
753
+ ```tsx
754
+ <Article
755
+ cover={{ src: cover.url, alt: cover.alt }}
756
+ eyebrow="Field guide · Wetlands › Herons"
757
+ title="How to count herons at the north hide"
758
+ meta="Ranger 1 · Oct 16 · 4 min read"
759
+ actions={
760
+ <SegmentedControl
761
+ aria-label="Language"
762
+ variant="raised"
763
+ items={languages}
764
+ value={language}
765
+ onChange={(id) => setLanguage(id === 'es' ? 'es' : 'en')}
766
+ />
767
+ }
768
+ variant="outlined"
769
+ >
770
+ <Stack gap={4}>{paragraphs}</Stack>
771
+ </Article>
772
+ ```
773
+
774
+ `MarkdownView` draws Markdown someone wrote or pasted (`source`) in Scalewing's type, as React elements and never as HTML from a string: paragraphs, headings (`#` drawn as an `h2`, since the page has its own title, `##` an `h3`, `###` and deeper an `h4`, in the title, body and label sizes; a `#` followed straight by a `###` skips a level, as written), bullet and numbered lists, bold, italic, links, block quotes as a highlighted box (the accent's tint with an accent bar; a link or photo caption in it is the text colour), line breaks, thematic breaks (a `Separator`) and photos. A link opens only `http`, `https` or `mailto` addresses, read as the browser would (`new URL`), in a new tab with `rel="noopener noreferrer"`, and carries your `newTabLabel` (required, such as "(opens in a new tab)") as hidden words; reference links and autolinks follow the same rule, and any other scheme (such as `javascript:` or `data:`), a relative or protocol-relative address shows as the link's plain words. Links are the accent, underlined; in the harvest palette's dark scheme the accent is 4.24:1 on the surface, a palette gap the tokens tests record. The only image is `![caption](photo:<id>)`: `resolveImage(id)` returns its `{ src, alt? }` or `null`; it is drawn as a figure at the page's width with the caption under it, outside any paragraph, when its `src` is relative, `https:`, `http:`, `blob:` or a `data:` PNG, JPEG, GIF, WebP, AVIF or SVG and it has words (its `alt`, else the caption). Keep `resolveImage` stable (`useCallback`): a new function reads the source again. Raw HTML (a tag's words stay), scripts, tables, other images and unresolved or wordless photos are left out; code is shown as plain text without highlighting. Quotes, lists and marks nested past 16 levels are shown as their words (a block reaching the parser's 128th level, where it stops reading, is reported once as it may be lost), and the parser's work stays linear, so a realistic 50,000-character page reads in about 7 ms and the slowest crafted one in about a tenth of a second (measured in the request). Each heading gets an id from its words, after `idPrefix`: give every view on a page its own (such as `"page-"`; `''`, the default, is none), or a heading such as "Content" takes the page's `content` id; repeats get `-2`, `-3`. `inspectMarkdown(source, { resolveImage, idPrefix })` gives the same `headings` (`id`, `level`, `text`) for an "On this page" `Nav`, the first 100 `omissions` (`kind`: `html`, `table`, `image`, `link`, `code` or `limit`, and up to 80 characters of `source`) and `omissionCounts` for every kind, for an editor to say what will not be shown. The parser is `markdown-it` (CommonMark, with GFM tables only so they are recognised and left out); its HTML renderer is never used. A `source` that is not text, an `idPrefix` other than `''` or a letter followed by letters, digits, `-` or `_`, or a `resolveImage` that is not a function throws a `RangeError` in both; a blank `newTabLabel` throws in the view.
775
+
776
+ ```tsx
777
+ // One stable resolver for both: a new function reads the source again.
778
+ const resolveImage = useCallback((id: string) => photos[id] ?? null, [photos]);
779
+
780
+ const { headings, omissions } = inspectMarkdown(page.body, {
781
+ resolveImage,
782
+ idPrefix: 'page-',
783
+ });
784
+
785
+ <MarkdownView
786
+ source={page.body}
787
+ resolveImage={resolveImage}
788
+ idPrefix="page-"
789
+ newTabLabel="(opens in a new tab)"
790
+ />;
791
+ ```
792
+
698
793
  `Separator` divides sections with a token-colored line. It is horizontal by default; use `orientation="vertical"` in a flex row. Set `decorative` when the line only supports layout so assistive technology ignores it.
699
794
 
700
795
  `DescriptionList` pairs terms with what each says, one `DescriptionItem` per row: a `dl` whose items each group a `dt` (`term`) with its `dd` (`children`). From `md` up the term sits in a start column as wide as the widest term, up to 40% of the list, and the detail beside it; every row aligns to its top, so a term and its detail's first line start level, and a hairline divides the rows, with no space above the first row or below the last. Below `md` each term sits over its detail. The list sets only the layout: pass your own `Text` for the term and the detail. Each child of a term or a detail is its own line with its own line height (a column), as wide as its content unless it sets its own width (a `Progress` fills the detail; a `Field` keeps its input's width): wrap inline content, such as words and a link on one line, in one element such as an `Inline`. Long unbreakable words wrap inside their column. A `dl` has no ARIA role, so name a list with a visible heading, and `aria-labelledby` on the `section` around it, rather than an `aria-label` on the list.
701
796
 
797
+ `layout="spread"` (opt-in; the default is `columns`) sets each row on its own two columns at every width, for a short box of facts such as a side panel's details: the term at the row's start, as wide as its words up to half the row, and the detail at the row's end, its lines end-aligned. The hairlines, padding and top alignment are the same, and a phone keeps each term beside its detail. An unknown `layout` throws a `RangeError`; `descriptionListLayouts` lists the layouts.
798
+
702
799
  ```tsx
703
800
  <DescriptionList>
704
801
  <DescriptionItem
@@ -743,6 +840,36 @@ A bar in its own column reads as empty cells to assistive technology, which is f
743
840
 
744
841
  `ListGroup` is rows in one list with a hairline between each pair, the web twin of react-native's: a `ul` (its list role kept) named by `aria-label` or `aria-labelledby`. `variant="panel"` (the default) is one bordered panel; `plain` draws no panel and a hairline above the first row too, for rows inside a `Card`. Each `ListRow` has a `title` (weight 600, or the body weight with `titleAppearance="plain"`; `tone="danger"` for a row that ends something), an optional muted `detail` line, an optional `value` at its end (weight 600, tabular figures; `valueTone` `success`, `danger`, `muted`, `accent` or `warning`, the warning colour's ink moved halfway to the text colour so it keeps 4.5:1 on the surface in every palette; `valueBadge` draws the value as a `Badge` in that tone instead, such as a status, still read in the row's name, never with `valueTone`; `valueDetail` a muted line under it) and an optional `leading` mark such as a status dot, hidden from assistive technology, so the words say what it means. With `onPress` the whole row is a button, and with `href` a link (`onPress` then takes a plain press for a client router); either adds a chevron, a quiet fill under the pointer and 44 px on a coarse pointer, and the row is named by its title and value and described by its detail lines (`aria-label` replaces the name). `selected` makes a pressable row a choice: a check while true, `aria-pressed` on a button or `aria-current` on a link (on a row with neither `onPress` nor `href` it throws, since nothing could read the state). `accessory="none"` hides the chevron of a row that acts in place; `disabled` blocks presses. A row with neither `onPress` nor `href` only states something. `maxHeight={{ rows }}` bounds the list to that many rows of a title and one detail line, the rest scrolling inside its own named region, which shades an edge with more rows past it; a bounded list must be named. `footer` stays pinned under the rows, outside the scroll, such as a `ListRow` with the count and the total (a row there is not a list item). An empty title or value, an unknown variant, tone or badge tone, a `valueDetail`, `valueTone` or `valueBadge` without a value, a value both toned and a badge, or a bad row count throws a `RangeError`.
745
842
 
843
+ A row can also carry three trailing parts. `unread` marks it new for the person, such as a document they have not opened: an accent dot after the title's last word (drawn as a border, so forced colors keep it in the text color), hidden from assistive technology, and `unreadLabel` (required while `unread`, such as "New") is read after the title, in a pressable row's name ("Heron calls New"). `tag` is a short status before the row's end, such as a `Badge` ("Only in English"), centred on the row and never wrapped; a pressable row is described by it after its detail lines. `action` is a control after the row and outside its press area, such as an `ActionMenu` of the row's own commands named by the consumer ("More for Heron calls"): it is the row's sibling in the list item, centred on the row, after it in the Tab order, and a press on it never opens the row; it keeps the row's end inset (a space-4 in a panel or outside a ListGroup, none in a plain list), and where `:has` is supported the hover fill spans the row and its control. An empty slot (`undefined`, `null`, `false` or `''`, as conditional JSX gives) draws nothing, for `leading` too. Pair `action` with `accessory="none"` when the row's own chevron would sit beside it. An unread row without `unreadLabel`, or a blank one, throws a `RangeError`. An `aria-label` replaces the whole name, unread words included.
844
+
845
+ `truncate` (opt-in) keeps a row's title and its detail line to one line each, cut with "…" where they do not fit, for a long list read at a glance such as a shelf of documents. The title's words sit in their own box, so the unread dot follows the cut words and is never hidden; the whole words stay in the row's name and description and in a `title` tooltip on the words and the detail. The value, its detail, the tag and the action keep their words whole. A tooltip needs a pointer, so on a touch screen or from the keyboard the cut words are whole only in what the row opens and in what assistive technology reads; keep `truncate` for rows that open their whole content, or whose words a person does not need in full to choose.
846
+
847
+ ```tsx
848
+ <ListRow
849
+ leading={
850
+ <GlyphTile hue="orange">
851
+ <Feather />
852
+ </GlyphTile>
853
+ }
854
+ title={guide.title}
855
+ detail={guide.detail}
856
+ href={guide.href}
857
+ onPress={() => router.push(guide.href)}
858
+ unread={guide.isNew}
859
+ unreadLabel="New"
860
+ tag={guide.onlyOne ? <Badge>Only in English</Badge> : undefined}
861
+ accessory="none"
862
+ action={
863
+ <ActionMenu
864
+ label={`More for ${guide.title}`}
865
+ trigger={<Ellipsis />}
866
+ align="end"
867
+ items={items}
868
+ />
869
+ }
870
+ />
871
+ ```
872
+
746
873
  ```tsx
747
874
  <Card padding={4}>
748
875
  <Text as="h3" id="look" variant="label">
@@ -783,6 +910,10 @@ A bar in its own column reads as empty cells to assistive technology, which is f
783
910
 
784
911
  `Text` steps `display` and `heading` down to the compact token sizes below the `md` breakpoint; consumers do not size type per viewport.
785
912
 
913
+ `sw-container-reading` centres a column at the reading width (`--sw-reading-max`, 46rem, `Article`'s), as `sw-container` centres a page's column at the container width (72rem): `width: 100%`, the max width and auto inline margins, full width below it. Use it for a form or text that reads better narrower than the page, such as an open form of one column. As with `sw-container`, the width is the content box's, so put padding or a border on a child.
914
+
915
+ `sw-font-mono` sets an element, or a native text control such as the `textarea` in a `Field`, in the platform's monospace face (`ui-monospace, monospace`, the generic names only) at the size it already has, for words read as typed code or marks, such as a Markdown source being written. It outranks the canvas's text control rules.
916
+
786
917
  `Text` has no margin of its own. Its default `p` and `h1`–`h4` elements drop the browser's block margins, so the `gap` of the `Stack` or `Inline` around it alone sets the spacing; put more space in that `gap`, not in a margin. The reset has zero specificity (`:where(.sw-text-*)`), so an authored margin, such as `sw-sr-only` or a consumer class, still applies.
787
918
 
788
919
  `Text` takes an optional `align` (`start`, `center`, `end`) mapped to generated `sw-text-align-*` classes, for a heading that must stay centered when it wraps.
@@ -874,6 +1005,8 @@ A strip row with `entry` makes its counts editable: each column holds a `Stepper
874
1005
 
875
1006
  `FilterChips` options for chips that sit above a list, all opt-in: `labelVisuallyHidden` keeps `label` as the group's name without drawing it, for chips whose words and place already say what they filter; `countAppearance="inline"` (`filterChipCountAppearances`, default `"figure"`) writes the count in the chip's own words after a middle dot ("Open · 3"), the dot hidden so the name reads "Open 3"; `overflow="scroll"` (`filterChipsOverflows`, default `"wrap"`) keeps the chips on one row that scrolls sideways inside its own box, never the page, each chip on one line, a focused chip scrolled into view and the focus ring unclipped. `multiple` turns several chips on at once, such as the days of a week: each chip is a native checkbox and its own Tab stop, `value` is a list of the chosen values (empty for none) and `onChange` receives every chosen value in the options' order. A multiple group cannot be `required` (nothing makes a checkbox group required); say what is needed in `error`. A value that is no option, a multiple `value` that is not a list, `required` with `multiple`, or an unknown count appearance or overflow throws a `RangeError`.
876
1007
 
1008
+ An option's `tone` (`filterChipTones`, `FilterChipTone`; default `"neutral"`) set to `"warning"` draws that chip, while it is not chosen, in the warning tint with its words and count in the warning ink (a warning `Notice`'s fill and words, 4.5:1 in every palette and scheme), for a choice that shows what needs a look, such as documents missing a translation. Its words must say why; the tint only draws the eye. Chosen, it is the accent like every chip; with a zero count it is quiet like every chip; it keeps the chips' hairline, so an `error`'s danger edge still shows; forced colours draw it as every chip. An unknown tone throws a `RangeError`.
1009
+
877
1010
  ```tsx
878
1011
  <FilterChips
879
1012
  label="Nocturnal surveys"
@@ -898,6 +1031,52 @@ A strip row with `entry` makes its counts editable: each column holds a `Stepper
898
1031
  { value: 'sat', label: 'Sat' },
899
1032
  ]}
900
1033
  />
1034
+
1035
+ <FilterChips
1036
+ label="Camera traps to check"
1037
+ labelVisuallyHidden
1038
+ multiple
1039
+ value={unchecked}
1040
+ onChange={setUnchecked}
1041
+ options={[
1042
+ { value: 'unchecked', label: 'Not checked this week', count: 2, tone: 'warning' },
1043
+ ]}
1044
+ />
1045
+ ```
1046
+
1047
+ `FilterChipsRow` lays several `FilterChips` groups on one row, for filters that need more than one kind of choice, such as kinds (one choice) and a toggle that narrows them (a checkbox). Each group keeps its own name, choices and Tab stops; the row is a plain `div` that lays them out a space-2 apart, their chips level along the bottom (an error under one group lifts its chips above the others'). `end` sets a group apart at the row's end, and at its own line's end when the row wraps. `overflow` (`"wrap"` by default, or `"scroll"`) wraps the groups onto more lines, or keeps every group's chips on one row that scrolls sideways together inside the row's own box, never the page, the focus ring unclipped and a focused chip scrolled into view; inside a scrolling row a group's own `overflow` gives way, and a group is as wide as its chips, so a drawn label or an `error` wraps under them. An empty `end` draws nothing; an unknown `overflow` throws a `RangeError`.
1048
+
1049
+ ```tsx
1050
+ <FilterChipsRow
1051
+ overflow="scroll"
1052
+ end={
1053
+ <FilterChips
1054
+ label="Not translated"
1055
+ labelVisuallyHidden
1056
+ multiple
1057
+ countAppearance="inline"
1058
+ value={untranslated}
1059
+ onChange={setUntranslated}
1060
+ options={[
1061
+ {
1062
+ value: 'untranslated',
1063
+ label: 'Not translated',
1064
+ count: 2,
1065
+ tone: 'warning',
1066
+ },
1067
+ ]}
1068
+ />
1069
+ }
1070
+ >
1071
+ <FilterChips
1072
+ label="Kinds"
1073
+ labelVisuallyHidden
1074
+ countAppearance="inline"
1075
+ value={kind}
1076
+ onChange={setKind}
1077
+ options={kinds}
1078
+ />
1079
+ </FilterChipsRow>
901
1080
  ```
902
1081
 
903
1082
  `DateField` is a controlled date-only field that draws its own calendar. Supply `value`, `min`, and `max` as valid `YYYY-MM-DD` dates; use an empty `value` for a blank field. Its callback returns a date-only string or an empty string, without timezone conversion. Invalid serialized dates and inverted bounds throw; a value outside the bounds remains visible with invalid styling. `required` marks the label with the same `aria-hidden` asterisk as `Field` and sets the entry's native `required`. Its label is drawn as `Field`'s (the same label words and mark inside a `<label>` that keeps the canvas type), so its label row and its gap to the entry match a `Field` beside it. For a blank required field, supply `error` when form validation runs to show the message and invalid styling.
@@ -51,8 +51,10 @@ export type ActionMenuProps = {
51
51
  /**
52
52
  * `text` draws the trigger as its words with a chevron after them, no
53
53
  * fill or outline until hovered or open, such as a person's name opening
54
- * their account menu; `framed` (the default) is the small outlined
55
- * control.
54
+ * their account menu; `icon` draws the `trigger` glyph alone in a round
55
+ * quiet button with no chevron, such as a row's ••• menu, named by
56
+ * `label` (an icon trigger needs a `trigger`); `framed` (the default) is
57
+ * the small outlined control.
56
58
  */
57
59
  triggerAppearance?: ActionMenuTriggerAppearance;
58
60
  };
@@ -2,8 +2,9 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId, useLayoutEffect, useRef, useState, } from 'react';
4
4
  import { cx } from '../class-names.js';
5
- import { actionMenuTriggerAppearances, actionMenuTriggerTextClass, } from '../css/css-action-menu-trigger.js';
5
+ import { actionMenuTriggerAppearances, actionMenuTriggerIconClass, actionMenuTriggerTextClass, } from '../css/css-action-menu-trigger.js';
6
6
  import { ActionMenuList } from './action-menu/ActionMenuList.js';
7
+ import { assertActionMenu } from './action-menu/assert-action-menu.js';
7
8
  import { tabStop } from '../tab-stop.js';
8
9
  export { actionMenuTriggerAppearances };
9
10
  function nextEnabled(items, current, direction) {
@@ -22,12 +23,7 @@ export function ActionMenu({ label, items, header, trigger, disabled = false, al
22
23
  const openRef = useRef(false);
23
24
  const [open, setOpen] = useState(false);
24
25
  const [focusIndex, setFocusIndex] = useState(-1);
25
- if (items.some((item) => (item.detail !== undefined && !item.detail.trim()) ||
26
- (item.detailLang !== undefined &&
27
- (!item.detailLang.trim() || item.detail === undefined))))
28
- throw new RangeError('ActionMenu item detail and detailLang must not be blank; detailLang needs a detail');
29
- if (!actionMenuTriggerAppearances.includes(triggerAppearance))
30
- throw new RangeError(`Unknown ActionMenu triggerAppearance: ${String(triggerAppearance)}`);
26
+ assertActionMenu({ items, trigger, triggerAppearance });
31
27
  const enabled = !disabled && items.some((item) => !item.disabled);
32
28
  useLayoutEffect(() => {
33
29
  if (!enabled && open)
@@ -94,5 +90,5 @@ export function ActionMenu({ label, items, header, trigger, disabled = false, al
94
90
  close(true);
95
91
  item.onSelect();
96
92
  }
97
- return (_jsxs("div", { className: "sw-action-menu", children: [_jsx("button", { "aria-controls": open ? menuId : undefined, "aria-expanded": open, "aria-haspopup": "menu", "aria-label": label, className: cx('sw-action-menu-trigger', triggerAppearance === 'text' && actionMenuTriggerTextClass), disabled: !enabled, onClick: () => (open ? close(true) : openMenu()), onKeyDown: onTriggerKeyDown, ref: triggerRef, tabIndex: tabStop, type: "button", children: triggerAppearance === 'text' ? (_jsx("span", { children: trigger ?? label })) : ((trigger ?? label)) }), open ? (_jsx(ActionMenuList, { align: align, focusIndex: focusIndex, header: header, id: menuId, items: items, label: label, onDismiss: () => close(), onFocusIndex: setFocusIndex, onKeyDown: onMenuKeyDown, onSelect: select, triggerRef: triggerRef })) : null] }));
93
+ return (_jsxs("div", { className: "sw-action-menu", children: [_jsx("button", { "aria-controls": open ? menuId : undefined, "aria-expanded": open, "aria-haspopup": "menu", "aria-label": label, className: cx('sw-action-menu-trigger', triggerAppearance === 'text' && actionMenuTriggerTextClass, triggerAppearance === 'icon' && actionMenuTriggerIconClass), disabled: !enabled, onClick: () => (open ? close(true) : openMenu()), onKeyDown: onTriggerKeyDown, ref: triggerRef, tabIndex: tabStop, type: "button", children: triggerAppearance === 'text' ? (_jsx("span", { children: trigger ?? label })) : ((trigger ?? label)) }), open ? (_jsx(ActionMenuList, { align: align, focusIndex: focusIndex, header: header, id: menuId, items: items, label: label, onDismiss: () => close(), onFocusIndex: setFocusIndex, onKeyDown: onMenuKeyDown, onSelect: select, triggerRef: triggerRef })) : null] }));
98
94
  }
@@ -0,0 +1,60 @@
1
+ import { type CardVariant } from '@scalewing/tokens';
2
+ import { type HTMLAttributes, type ReactNode } from 'react';
3
+ /** The photo across the top of an article. */
4
+ export type ArticleCover = {
5
+ src: string;
6
+ /** What the photo shows; `''` when it only decorates. */
7
+ alt: string;
8
+ };
9
+ export declare const articleTitleElements: readonly ["h1", "h2", "h3"];
10
+ export type ArticleTitleElement = (typeof articleTitleElements)[number];
11
+ export type ArticleProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'title'> & {
12
+ /** The article's title, in heading type. */
13
+ title: string;
14
+ /**
15
+ * `h1` (the default) on the article's own page; `h2` or `h3` where it
16
+ * sits under another page's headings.
17
+ */
18
+ titleAs?: ArticleTitleElement;
19
+ /** A short line over the title, such as "Store procedure · Services". */
20
+ eyebrow?: ReactNode;
21
+ /** A muted line under the title, such as "Manager 1 · Oct 16 · 4 min read". */
22
+ meta?: ReactNode;
23
+ /** A photo across the top, edge to edge; `null` is none. */
24
+ cover?: ArticleCover | null;
25
+ /** Controls at the end of the eyebrow's row, such as a language switch. */
26
+ actions?: ReactNode;
27
+ /** The card look, as `Card`'s. */
28
+ variant?: CardVariant;
29
+ /** The article's body, under a hairline. */
30
+ children?: ReactNode;
31
+ };
32
+ /**
33
+ * A page that reads like a post, on a card at a reading width: an optional
34
+ * cover photo edge to edge across the top, then the eyebrow (with the
35
+ * consumer's actions at its row's end), the title in heading type, the
36
+ * muted meta line, a hairline, and the body. The article is named by its
37
+ * title (an `aria-labelledby` you pass replaces that). The ref is the
38
+ * `article` element.
39
+ */
40
+ export declare const Article: import("react").ForwardRefExoticComponent<Omit<HTMLAttributes<HTMLElement>, "title" | "children"> & {
41
+ /** The article's title, in heading type. */
42
+ title: string;
43
+ /**
44
+ * `h1` (the default) on the article's own page; `h2` or `h3` where it
45
+ * sits under another page's headings.
46
+ */
47
+ titleAs?: ArticleTitleElement;
48
+ /** A short line over the title, such as "Store procedure · Services". */
49
+ eyebrow?: ReactNode;
50
+ /** A muted line under the title, such as "Manager 1 · Oct 16 · 4 min read". */
51
+ meta?: ReactNode;
52
+ /** A photo across the top, edge to edge; `null` is none. */
53
+ cover?: ArticleCover | null;
54
+ /** Controls at the end of the eyebrow's row, such as a language switch. */
55
+ actions?: ReactNode;
56
+ /** The card look, as `Card`'s. */
57
+ variant?: CardVariant;
58
+ /** The article's body, under a hairline. */
59
+ children?: ReactNode;
60
+ } & import("react").RefAttributes<HTMLElement>>;
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cardVariants } from '@scalewing/tokens';
3
+ import { forwardRef, useId } from 'react';
4
+ import { cx } from '../class-names.js';
5
+ import { Separator } from './Separator.js';
6
+ import { Text } from './Text.js';
7
+ export const articleTitleElements = ['h1', 'h2', 'h3'];
8
+ function hasContent(node) {
9
+ return (node !== undefined &&
10
+ node !== null &&
11
+ typeof node !== 'boolean' &&
12
+ node !== '');
13
+ }
14
+ function assertArticle({ title, titleAs, cover, variant }) {
15
+ if (typeof title !== 'string' || title.trim() === '')
16
+ throw new RangeError('Article title must be non-empty text');
17
+ if (titleAs !== undefined && !articleTitleElements.includes(titleAs))
18
+ throw new RangeError(`Unknown Article titleAs: ${String(titleAs)}`);
19
+ if (variant !== undefined && !cardVariants.includes(variant))
20
+ throw new RangeError(`Unknown Article variant: ${String(variant)}`);
21
+ if (cover !== undefined && cover !== null) {
22
+ if (typeof cover.src !== 'string' || cover.src.trim() === '')
23
+ throw new RangeError('Article cover src must be non-empty text');
24
+ if (typeof cover.alt !== 'string')
25
+ throw new RangeError("Article cover alt must be text ('' for a photo that only decorates)");
26
+ }
27
+ }
28
+ /**
29
+ * A page that reads like a post, on a card at a reading width: an optional
30
+ * cover photo edge to edge across the top, then the eyebrow (with the
31
+ * consumer's actions at its row's end), the title in heading type, the
32
+ * muted meta line, a hairline, and the body. The article is named by its
33
+ * title (an `aria-labelledby` you pass replaces that). The ref is the
34
+ * `article` element.
35
+ */
36
+ export const Article = forwardRef(function Article(props, ref) {
37
+ assertArticle(props);
38
+ const { title, titleAs = 'h1', eyebrow, meta, cover, actions, variant = 'glass', children, className, ...rest } = props;
39
+ const titleId = useId();
40
+ const top = hasContent(eyebrow) || hasContent(actions);
41
+ return (_jsxs("article", { "aria-labelledby": titleId, className: cx('sw-card', `sw-card-${variant}`, 'sw-article', className), ref: ref, ...rest, children: [cover ? (_jsx("img", { alt: cover.alt, className: "sw-article-cover", src: cover.src })) : null, _jsxs("div", { className: "sw-article-body", children: [_jsxs("header", { className: "sw-article-header", children: [top ? (_jsxs("div", { className: "sw-article-top", children: [hasContent(eyebrow) ? (_jsx("div", { className: "sw-article-eyebrow", children: eyebrow })) : null, hasContent(actions) ? (_jsx("div", { className: "sw-article-actions", children: actions })) : null] })) : null, _jsx(Text, { as: titleAs, className: "sw-article-title", id: titleId, variant: "heading", children: title }), hasContent(meta) ? (_jsx("div", { className: "sw-article-meta", children: meta })) : null] }), _jsx(Separator, { decorative: true }), hasContent(children) ? (_jsx("div", { className: "sw-article-content", children: children })) : null] })] }));
42
+ });
@@ -1,15 +1,25 @@
1
1
  import { type HTMLAttributes, type ReactNode } from 'react';
2
+ import { type DescriptionListLayout } from './description-list-layout.js';
3
+ export { descriptionListLayouts, type DescriptionListLayout, } from './description-list-layout.js';
2
4
  export type DescriptionListProps = Omit<HTMLAttributes<HTMLDListElement>, 'children'> & {
3
5
  /** The list's `DescriptionItem`s. */
4
6
  children: ReactNode;
7
+ /**
8
+ * `columns` (the default): the terms in a start column and the details
9
+ * beside them, each term over its detail below `md`. `spread`: each
10
+ * row's term at its start and its detail at its end, end-aligned, side
11
+ * by side at every width, for a short box of facts.
12
+ */
13
+ layout?: DescriptionListLayout;
5
14
  };
6
15
  /**
7
16
  * Terms and what each one says, one item per row: the term in a start
8
17
  * column as wide as the widest term (up to 40% of the list), the detail
9
18
  * beside it, a hairline between rows, every row aligned to its top. Below
10
- * the layout breakpoint (`md`) each term sits over its detail.
19
+ * the layout breakpoint (`md`) each term sits over its detail. `spread`
20
+ * sets each detail at its row's end instead, beside its term at every width.
11
21
  */
12
- export declare function DescriptionList({ children, className, ...rest }: DescriptionListProps): import("react").JSX.Element;
22
+ export declare function DescriptionList({ children, className, layout, ...rest }: DescriptionListProps): import("react").JSX.Element;
13
23
  export type DescriptionItemProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
14
24
  /**
15
25
  * What the row describes, such as a name; the consumer's own `Text`. Each
@@ -1,13 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../class-names.js';
3
+ import { assertDescriptionListLayout, } from './description-list-layout.js';
4
+ export { descriptionListLayouts, } from './description-list-layout.js';
3
5
  /**
4
6
  * Terms and what each one says, one item per row: the term in a start
5
7
  * column as wide as the widest term (up to 40% of the list), the detail
6
8
  * beside it, a hairline between rows, every row aligned to its top. Below
7
- * the layout breakpoint (`md`) each term sits over its detail.
9
+ * the layout breakpoint (`md`) each term sits over its detail. `spread`
10
+ * sets each detail at its row's end instead, beside its term at every width.
8
11
  */
9
- export function DescriptionList({ children, className, ...rest }) {
10
- return (_jsx("dl", { className: cx('sw-description-list', className), ...rest, children: children }));
12
+ export function DescriptionList({ children, className, layout = 'columns', ...rest }) {
13
+ assertDescriptionListLayout(layout);
14
+ return (_jsx("dl", { className: cx('sw-description-list', layout === 'spread' && 'sw-description-list-spread', className), ...rest, children: children }));
11
15
  }
12
16
  /** One row of a `DescriptionList`: a `dt` and its `dd` in one group. */
13
17
  export function DescriptionItem({ term, children, className, ...rest }) {
@@ -0,0 +1,41 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type DocumentViewerLabels } from './document-viewer/document-viewer-labels.js';
3
+ export type { DocumentViewerLabels };
4
+ export type DocumentViewerProps = {
5
+ /**
6
+ * The PDF's bytes, never a URL. The viewer draws from a copy. Other
7
+ * bytes (another object) open again at the first page, so keep this the
8
+ * same object between renders (state or a memo).
9
+ */
10
+ data: ArrayBuffer | Uint8Array;
11
+ /**
12
+ * The URL of the bundled worker entry `@scalewing/react/document-viewer-worker`
13
+ * (Scalewing's PDF.js worker, always the viewer's version), such as Vite's
14
+ * `import workerSrc from '@scalewing/react/document-viewer-worker?worker&url'`.
15
+ */
16
+ workerSrc: string;
17
+ /** Names the viewer, such as the document's title. */
18
+ label: string;
19
+ labels: DocumentViewerLabels;
20
+ /** The page shown, from 1; left out, the viewer keeps its own. */
21
+ page?: number;
22
+ /** Called with the page a step goes to. */
23
+ onPageChange?: (page: number) => void;
24
+ /** The failed state's actions, such as Try again and Download PDF. */
25
+ failedAction?: ReactNode;
26
+ /** Called once the PDF opens, with its page count. */
27
+ onLoad?: (info: {
28
+ pageCount: number;
29
+ }) => void;
30
+ /** Called when the PDF cannot be opened or a page cannot be drawn. */
31
+ onError?: (error: unknown) => void;
32
+ };
33
+ /**
34
+ * A PDF drawn one page at a time from its bytes with PDF.js, fitted to the
35
+ * width it is given, with previous and next steps and "Page n of m" in a
36
+ * polite live region. Two fingers, or `+`, `-` and `0` on the focused
37
+ * page, zoom it. The page's text sits over the drawing, transparent, so it
38
+ * can be read by assistive technology and selected. It opens nothing from
39
+ * the network beyond the worker, and has no download or print of its own.
40
+ */
41
+ export declare function DocumentViewer({ data, workerSrc, label, labels, page: pageProp, onPageChange, failedAction, onLoad, onError, }: DocumentViewerProps): import("react").JSX.Element;